# -*- mode: python; coding: utf-8; -*-
#---------------------------------------------------------------------------##
#
# Copyright (C) 1998-2003 Markus Franz Xaver Johannes Oberhumer
# Copyright (C) 2003 Mt. Hood Playing Card Co.
# Copyright (C) 2005-2010 Skomoroh
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
#---------------------------------------------------------------------------##
__docformat__ = 'restructuredtext en'

import random
import time
from math import factorial
import logging

Logger = logging.getLogger('PySolFC.Mahjongg.Common')

from ... import autostacks
from ... import comparecards as cmp_cards
from ... import formlib as layout
from ... import pilelib as stack
from ...gamelib import state, game
from ...piles import mahjongg as mstacks
from ...pilelib import move as a_move
from ...settings import CONF_OPT


factor = lambda s: (factorial(len(s)) / 2 / factorial(max(len(s) - 2, 0)))


class MahjonggShuffle(object):
    """
    Mahjongg tile solvable tile placement for any given layout.
    """

    __max_time = 5.0
    __max_iters = 288
    __time = None
    __iters = None
    __cards = []
    __rows = []
    __pairs = []

    def __init__(self, rows, cards):
        self.__cards = cards[:]
        self.__rows = rows
        self.__time = time.time()
        self.__iters = 0
        self.__max_time = 5.0 # ms
        self.__max_iters = 2 * len(cards)

    def _timeout(self):
        """
        _timeout() -> bool

        :returns: max_iters or max_time reached?
        """
        return (self.__iters > self.__max_iters or
                time.time() - self.__time > self.__max_time)

    def is_blocked(self, pile, cards):
        """
        is_blocked(self, pile, cards) -> bool

        Check blocked method. Examine the pile blockmap above, left and right
        for a place with out tile that contained a tile before.

        :Parameters:
          pile : Stack
            possible pile to place a tile.

          cards : sequence
            possible available piles. None: available, True: pile dropped tile

        :returns: pile is blocked?
        """
        # any of above blocks
        for stack in pile.blockmap.above:
            if cards[stack.id] is None:
                return True

        # any of left blocks
        for stack in pile.blockmap.left:
            if cards[stack.id] is None:
                break
        else:
            return False

        # any of right blocks
        for stack in pile.blockmap.right:
            if cards[stack.id] is None:
                return True
        return False

    def is_suitable(self, pile, cards):
        """
        is_suitable(self, pile, cards) -> bool

        Check suitable method. Examine the pile blockmap below, left with all
        left and right with all right for a place with out tile that contained
        a tile before.

        :Parameters:
          pile : Stack
            possible pile to place a tile.

          cards : sequence
            possible available piles. None: available, True: pile dropped tile

        :returns: pile is suitable?
        """
        for stack in pile.blockmap.below:
            # check if below stacks are non-empty
            if cards[stack.id] is None:
                return False

        for stack1 in pile.blockmap.left:
            if cards[stack1.id] is None:
                for stack2 in stack1.blockmap.all_left:
                    if cards[stack2.id] is not None:
                        # we have empty stack between two non-empty
                        return False

        for stack1 in pile.blockmap.right:
            if cards[stack1.id] is None:
                for stack2 in stack1.blockmap.all_right:
                    if cards[stack2.id] is not None:
                        # we have empty stack between two non-empty
                        return False
        return True

    def find_pair(self, cards):
        """
        find_pair(self, cards) -> bool

        Find pairing of ranks. Taking the next tile from cards find another
        that isSameMahjonggRank()

        :Parameters:
          cards : tuple(Card...)
            available tiles yet to place

        :returns: tuple of a pair of matching rank tiles.
        """
        c1, c2 = cards.pop(0), None
        for card in cards:
            if cmp_cards.isSameMahjonggRank([c1] + [card]):
                c2 = card
                cards.remove(card)
                break
        return c1, c2

    def find_piles(self, piles):
        """
        find_piles(self, pile) -> bool

        Find pairing of ranks. Taking the next tile from cards find another
        that isSameMahjonggRank()

        :Parameters:
          piles : tuple(Stack...)
            available piles yet to recieve

        :returns: tuple of a pair of empty piles.
        """
        while True:
            r1 = random.randrange(0, len(piles))
            r2 = random.randrange(0, len(piles)-1)
            if r2 == r1:
                continue
            if not ((r1, r2,) in self.__pairs or (r2, r1,) in self.__pairs):
                self.__pairs.append((r1, r2,))
                return piles[r1], piles[r2]

    def create_easy_placement(self, cards, new_cards):
        """
        create_easy_placement(self, cards, new_cards) -> tuple(Card...)

        Using the is_blocked method place tiles on piles so there will be some
        playable piles available.

        :Parameters:
          cards : tuple(Card...)
            available piles yet to recieve

          new_cards : sequence
            possible available piles. None: available, True: pile dropped tile

        :returns: tuple(Card...) | None
        """
        self.__iters += 1
        if self._timeout():
            return None
        if not cards:
            return new_cards
        nc = new_cards[:]
        # select two matching cards
        c1, c2 = self.find_pair(cards)
        # none-blocked stacks
        self.__pairs = []
        piles = [p for p in self.__rows
                 if nc[p.id] is None and not self.is_blocked(p, nc)]
        if len(piles) < 2:
            return None             # try another way
        for j in range(factor(piles)):
            if self._timeout():
                return None
            # add two selected cards to new_cards
            s1, s2 = self.find_piles(piles)
            nc[s1.id] = c1
            if self.is_blocked(s2, nc):
                nc[s1.id] = None
                continue
            nc[s2.id] = c2
            # check if this layout is solvable (backtracking)
            ret = self.create_easy_placement(cards[:], nc[:])
            if ret:
                ret = [x for x in ret if x is not True]
                return ret
            nc[s1.id] = nc[s2.id] = None # try another way
        return nc

    def create_hard_placement(self, cards, new_cards):
        """
        create_hard_placement(self, cards, new_cards) -> tuple(Card...)

        Using the is_suitable method place tiles on piles so there will be
        some playable piles available.

        :Parameters:
          cards : tuple(Card...)
            available piles yet to recieve

          new_cards : sequence
            possible available piles. None: available, True: pile dropped tile

        :returns: tuple(Card...) | None
        """
        self.__iters += 1
        if self._timeout():
            return None
        if not cards:
            return new_cards
        nc = new_cards[:]
        # select two matching cards
        c1, c2 = self.find_pair(cards)
        self.__pairs = []
        piles = [p for p in self.__rows
                 if nc[p.id] is None and self.is_suitable(p, nc)]
        for j in range(factor(piles)):
            if self._timeout():
                return None
            # select two suitable stacks
            s1, s2 = self.find_piles(piles)
            # check if s1 don't block s2
            nc[s1.id] = c1
            if not self.is_suitable(s2, nc):
                nc[s1.id] = None
                continue
            nc[s2.id] = c2
            # check if this layout is solvable (backtracking)
            ret = self.create_hard_placement(cards[:], nc)
            if ret:
                ret = [x for x in ret if x is not True]
                return ret
            nc[s1.id] = nc[s2.id] = None # try another way
        return None

    def easy_placement(self, game):
        """
        easy_placement(self, game) -> tuple(Card...)

        After marking dropped piles, using the is_blocked method place tiles
        on piles so there will be some playable piles available.

        :Parameters:
          game : Game
            current game

        :returns: tuple(Card...) | None
        """
        new_cards = [None] * len(game.s['rows'])
        for r in game.s['rows']:
            if r not in self.__rows:
                new_cards[r.id] = True
        while True:
            ret = self.create_easy_placement(self.__cards[:], new_cards[:])
            if ret:
                ret.reverse()
                return ret
            if self._timeout():
                break
            self.__iters = 0
        Logger.warning('Placement time-out before solvable game was created.')
        return None

    def hard_placement(self, game):
        """
        hard_placement(self, game) -> tuple(Card...)

        After marking dropped piles, using the is_suitable method place tiles
        on piles so there will be some playable piles available.

        :Parameters:
          game : Game
            current game

        :returns: tuple(Card...) | None
        """
        new_cards = [None] * len(game.s['rows'])
        for r in game.s['rows']:
            if r not in self.__rows:
                new_cards[r.id] = True
        while True:
            ret = self.create_hard_placement(self.__cards[:], new_cards[:])
            Logger.debug('time:', time.time() - self.__time)
            if ret:
                ret.reverse()
                return ret
            if self._timeout():
                break
            self.__iters = 0
        Logger.warning('Placement time-out before solvable game was created.')
        return None


class AbstractMahjonggGame(game.Game):
    """
    Using mahjongg tile sets create `__layout__` described and clear the
    `s.rows` of tiles by matching pairs of same rank or like bonus tiles.
    """
    _talon_ = stack.InitialDealTalon
    _foundation_ = mstacks.Foundation
    _row_ = mstacks.RowStack
    _hint_ = mstacks.Hint

    __VERSION__ = 7.70

    def get_tile_layout(self):
        """
        get_tile_layout() -> tuple(int, int, int), int, int, int

        Decode tile positions using __layout__ string. Each string starts with
        0 which is used for verification. Then each 3 char set following is
        processed for number of tile from level and coords.

        :returns: level and coords for each tile along with max coords
        """
        layout_ = self.__layout__
        assert (layout_.startswith('0') and (len(layout_) - 1) % 3 == 0 and
                layout_[1:].isalpha())

        tiles = []
        max_tl, max_tx, max_ty = -1, -1, -1
        t = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'

        for cn, cx, cy in zip(layout_[1::3], layout_[2::3], layout_[3::3]):
            level, height = divmod(t.find(cn), 7)  # from level place num tile
            tx, ty = t.find(cx), t.find(cy)  # coords
            max_tl = max(level + height, max_tl)
            max_tx, max_ty = max(tx, max_tx), max(ty, max_ty)
            for tl in range(level, level + height + 1):
                tiles.append((tl, tx, ty))

        assert len(tiles) == layout.NCARDS
        return tiles, max_tl, max_tx, max_ty

    def _create_game(self):
        tiles, max_tl, max_tx, max_ty = self.get_tile_layout()
        # start layout
        l, s = layout.Layout(self), self.s
        show_removed = CONF_OPT['mahjongg_show_removed']
        dx, dy = 3, -3
        sdx, sdy = 0, 0
        CW, CH = l.CW - sdx, l.CH - sdy
        dxx, dyy = abs(dx) * (max_tl + 1), abs(dy) * (max_tl + 1)  # 3D levels
        w = 3 * l.XM + (max_tx + 2) * CW / 2 + dxx + sdx + 80
        left_margin = 0
        h = 2 * l.YM + (max_ty + 2) * CH / 2 + dyy + sdy
        fdyy = 0
        if show_removed:
            fdxx, fdyy = abs(dx) * l.NDECKS, abs(dy) * l.NDECKS  # 3D levels
            left_margin = (l.XM + 4 * CW + fdxx + sdx)
            w += left_margin
            h = max(h, 2 * l.YM + 9 * CH + fdyy + sdy)
        self.table_size = (w, h)

        # set game extras
        self.check_dist = l.CW ** 2 + l.CH ** 2

        # sort tiles (for 3D)
        tiles.sort(key=lambda t: t[0] or -t[1] + t[2])

        # create a row stack for each tile and compute the tilemap
        tilemap = {}
        x0, y0 = left_margin, l.YM + dyy
        for level, tx, ty in tiles:
            x = x0 + (tx * CW) / 2 + level * dx
            y = y0 + (ty * CH) / 2 + level * dy
            s['rows'].append(self._row_(x, y, game=self, offset=(dx, dy)))

            # tilemap - each tile covers 4 positions
            for position in mstacks.iter_positions(level, tx, ty):
                tilemap[position] = s['rows'][-1]

        # compute blockmap
        mstacks.blockmap(s['rows'], tiles, tilemap)

        # create other stacks
        for i in range(4):
            for j in range(9):
                x, y = l.XM + i * CW, l.YM + fdyy + j * CH
                if not show_removed:
                    x += self.width + CW
                offset = (dx, dy,) if show_removed else (0, 0,)
                s['foundations'].append(self._foundation_(x, y, game=self,
                                                          offset=offset))
        self.texts['info'] = l.create_info_text(None, anchor='nw',
                                             dx=self.width - l.XM,
                                             dy=l.YM + dyy)
        coords = self.canvas.get_hidden_coords(self.images.size)
        s['talon'] = self._talon_(coords.x, coords.y, game=self)

        self.sg['Talons'] = [s['talon']]
        self.sg['dropstacks'] = list(s['rows'])
        self.sg['openstacks'] = list(s['foundations']) + list(s['rows'])

    def _pack_place(self, cards):
        """
        _pack_place([]) -> tuple

        Get a new copy of cards, shuffle and place cards according to first
        and base values.

        :returns: deck shuffled and positioned.
        """
        if CONF_OPT['mahjongg_create_solvable'] == False:
            return cards
        # try to create a solvable game
        place = placement.MahjonggShuffle(self.s['rows'], cards[:])
        if CONF_OPT['mahjongg_create_solvable'] == True:
            return place.easy_placement(self) or cards
        return place.hard_placement(self) or cards

    @state.move_state('deal')
    def _shuffle_tile(self):
        """
        _shuffle_tile([]) -> none

        Shuffle tiles during a game leaving dropped tile positions empty.
        """
        talon = self.s['talon']
        rows = []
        cards = []

        # record current positions and tiles on rows
        for r in self.s['rows']:
            if r.cards:
                rows.append(r)
                cards.append(r.cards[0])
        if not rows:
            return
        if self.can_play_sound:
            self._play_sound('turnwaste')
        self._move_do(a_move.ASaveSeedMove(self))  # FIXME
        # try to create a solvable game
        place = placement.MahjonggShuffle(rows, cards[:])
        if CONF_OPT['mahjongg_create_solvable'] == True:
            new_cards = place.easy_placement(self)
        new_cards = place.hard_placement(self)
        self.stats._move_shuffles += 1

        # Fallback if solvable game not possible or option is False.
        if new_cards is None or CONF_OPT['mahjongg_create_solvable'] == False:
            # move cards from rows to talon
            for pile in rows:
                self.deal(pile, talon, flip=True, frames=0)
            self._move_do(a_move.AShuffleStackMove(talon, sel.random))  # FIXME
        else:
            # move cards in order of new_cards from rows to talon
            for card in new_cards:
                for pile in rows:
                    if pile.cards and pile.cards[0] is card:
                        self.deal(pile, talon, flip=True, frames=0)
                        break

        # deal tiles back
        self.s['talon'].dealRow(rows=rows[:-12], frames=0)
        self.s['talon'].dealRow(rows=rows[-12:], frames=4)
        self.move_finish()

    def _start(self, **kw):
        """
        Deal initial cards for new game.
          - s[rows] 1 ea.

        .. \_start([]) -> None
        """
        self.s['talon'].dealRow(rows=self.s['rows'][:-12], frames=0)
        self.s['talon'].dealRow(rows=self.s['rows'][-12:])

    @property
    def is_complete(self):
        ncards = self['ncards']
        return sum([len(f.cards) for f in self.s['foundations']]) == ncards

    def _autopiles(self, event=None):
        """
        Return piles for auto or quick move.

        Overide to disable autoflip, autodrop, quickplay.

        .. \_autopiles(self, event=None) -> seq(seq(Pile ...), seq(Pile ...),
             seq(Pile ...))
        """
        return autostacks.none()

    @state.not_state('is_preview')
    def _update_text(self):
        if self.texts['info'] is None:
            return
        ncards = self['ncards']
        stacks = [s for s in self.s['rows'] if s.cards and not s.cardsBlock()]
        # find matching tiles
        pairs = 0
        for from_stack in stacks[::2]:
            for to_stack in stacks[1::2]:
                if to_stack._sequence(from_stack.cards[-1:] +
                                      to_stack.cards[-1:]):
                    pairs += 1
        if pairs == 0:
            text = _('No Free\nMatching\nPairs')
        else:
            text = p_.ngettext('%d Free\nMatching\nPair',
                                '%d Free\nMatching\nPairs', pairs) % pairs
        drop = sum(len(i.cards) for i in self.s['foundations'])
        r1 = p_.ngettext('%d\nTile\nRemoved\n\n',
                          '%d\nTiles\nRemoved\n\n', drop) % drop
        r2 = p_.ngettext('%d\nTile\nRemaining\n\n',
                          '%d\nTiles\nRemaining\n\n',
                           ncards - drop) % (ncards - drop)
        self.texts['info'].config(text=r1 + r2 + text)

    @property
    def _highlight_piles(self):
        """
        Get stacks to highlight for possible move.

        :return: `s.rows` >= 1.
        :rtype: tuple
        """
        return (self.s['rows'], 1)

#    def _get_closest_stack(self, cx, cy, stacks, dragstack):
#        closest, cdist = None, 999999999
#        # Since we only compare distances,
#        # we don't bother to take the square root.
#        for stack in stacks:
#            dist = (stack.x - cx)**2 + (stack.y - cy)**2
#            if dist < cdist:
#                # Mahjongg special: if the stack is very close, do
#                # not consider blocked stacks
#                if dist > self.check_dist or not stack.cardsBlock():
#                    closest, cdist = stack, dist
#        return closest